Repository navigation
fix(security): second ReDoS sweep (#174) - #175
Merged
Merged
Conversation
- api: the reset-request and invite address check is linear and checks the length first (isEmailAddress); public token endpoints refuse JSON bodies over 64 KiB with 413, streamed or not. - core-ts: linear redirect path trims and ancestorPaths below the first `//`; searchText fences, links, images and tags; SVG CSS comments, url() values and lengths (css-scan.ts). - schedules: linear CDATA, element and media url scanners (xml-scan.ts) and urlPath. - agent-runtime: linear SKILL.md frontmatter line parsers. - ui, docs: linear newline trims in the editor; isPagePath by lastIndexOf. - AGENTS.md rule 13 and a CONTRIBUTING note on regexes over external input. Each rewrite has an equivalence test against the old regex and a 100k-character adversarial case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
- ancestorPaths starts at the longest ancestor a redirect from can be (1024 characters), so many short segments stay bounded. - searchText's directive braces stay on one line (CR line ends were quadratic); stripFences keeps the next "\n" between openers. - mediaUrlValues keeps the next ">" and "url" (forwardFinder). - The editor trim test is renamed to .test.tsx so ui's vitest collects it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
- Every new timing assertion is 1.5 s on 200k inputs (more where the old regex grew slowly), as in #168's tests; the public reset test warms the app first. - ancestorPaths caps at 2 x 1024 UTF-16 units: a from's maxLength is counted in code points, so astral froms longer than 512 characters still match. The `//` cut is found on the whole path, so the first ancestor never ends in "/". - Cases for U+2029 and CRLF fences, U+2028/2029 directives, and a multi-enclosure feed (data-url kept as the regex read it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #174, the second ReDoS sweep found while verifying #173.
How I measured
Each site was timed in Node at 20k and 40k characters. A ×4 jump per doubling means quadratic.
Items
1. Unauthenticated email check and body cap (
packages/api)isEmailAddress(src/email.ts) checks the length (254) first. It then accepts exactly what/^[^\s@]+@[^\s@]+\.[^\s@]+$/accepted, in one pass: one@, a dot inside the domain, no whitespace.a@+ 40k dots, andpassword-reset.tsran it before the length cap.invites.tsandpassword-reset.tsboth use the new check, and theEMAILregex is gone.jsonBody(the public token routes: reset request, inspect and complete, invite inspect and accept) reads at mostJSON_BODY_MAX_BYTES= 64 KiB from the stream. Above that it answers413 payload_too_large, both whencontent-lengthsays so and when a chunked body runs past the cap.Verified in
test/public-input.test.ts:a . @ space, plus samples;content-lengthand streamed;2. Redirect paths (
packages/core-ts/src/redirects.ts,packages/schedules/src/feed-check.ts)normalisePathandjoinTargetusetrimEndChar. The old trims took 621 ms on 40k slashes.ancestorPathsstarts its walk below the first//. The contract'sfrompattern allows no empty segment, so an ancestor holding//can never match. A run of slashes used to yield one useless ancestor per slash.urlPathin feed-check uses an inline loop, since schedules does not depend on core-ts.Verified:
test/redos-174.test.ts: equivalence fornormalisePathandjoinTarget;ancestorPathsequals the old list filtered to validfroms;matchRedirectcases; 100k slash runs.schedules/test/xml-scan.test.ts:urlPath.3. Docs Worker
isPagePath(apps/docs/src/worker/paths.ts)Replaced with
lastIndexOf(".") <= lastIndexOf("/"). The old check took 622 ms on 40k dots.Verified:
apps/docs/test/paths.test.ts, with 5,000 random paths and 100k dots.4. Lazy scans, measured one by one:
search-rows.tsfences```a\nopeners, no closerstripFences(search-text-scan.ts): an index of closing lines by fence, so each opener is a lookup. It keeps the regex'sm-flag line starts (\n \r) and its fallback from a longer fence to shorter ones.search-rows.ts<[^>]*>(not in the issue)<a× nstripTagssearch-rows.tsimage and link patterns (not in the issue)svg.tsurl\s*\(\s*(['"]?)([^'")]*)\1\s*\)(not in the issue)url(+ 20k spacescssUrlValues; its two\s*and the value class overlap, so it is about cubicsvg.tswidth/height^\s*([0-9]*\.?[0-9]+)\s*(px)?\s*$(not in the issue)svgPixelsfeed-check.ts:157CDATA<![CDATA[× ncdataParts(xml-scan.ts)feed-check.ts:202mediaurl<enclosure url=× nmediaUrlValues: a tag whose candidates all failed is skipped to its>feed-check.tsblocks/child(not in the issue)<item× nelementContentsautomations.ts:450htmlHrefs.stops at a newlineVerified:
core-ts/test/redos-174.test.tsfuzzes 8,000 fence strings, including\r, and 5,000url(strings. It also runs a wholecheckSvgdocument withurl(plus 20k spaces in under 500 ms (attributes are read from a 64 KiB window, so the document case stays below it) andsearchTexton 100k adversarial characters.schedules/test/xml-scan.test.tsfuzzes CDATA (3,000), elements (6,000) and media urls (8,000), all with 100k cases.5.
skills.tsfrontmatter (packages/agent-runtime/src/frontmatter-lines.ts)/^\s+([^:]+):\s*(.*)$/was 650 ms on 40k spaces, and both\s*(.*)$tails backtracked before a lone\r.:gives a one-space key, and a line terminator after the value fails the line.loadSkillsincloudhal mcpandcloudhal studio, or bundled into a Worker), so they are not caller input today. Fixed anyway.Verified:
test/frontmatter-lines.test.ts, with 10,000 random lines including\rand, and 100k cases.6. Editor trims (
packages/ui/src/blocks/editor/markdown-blocks.ts)/\n*$/becomestrimEndChar(…, "\n"). The old trim took 644 ms on 40k inner newlines.Verified:
test/markdown-blocks-redos.test.tsx, with equivalence on 5,000 strings andmergeBlockswith a 100k-newline block. The existing codec tests still pass. Correction: in the first commit this file was named.test.ts, whichpackages/ui/vitest.config.ts(test/**/*.test.tsx) never collected, so it had not run. Round 2 renames it; it runs and passes (2 tests).Done-when items
redos-sweep-174.md, patch, for api, core-ts, schedules, agent-runtime and cli.@cloudhal/uiand the docs app are private; the editor fix ships in the CLI's Studio.Checked and left alone:
roles.tsemail check: it already checks the length first, so it is bounded at 254.MAIL_ADDRESSincli/config.tsandcloudflare/mail.ts: operator configuration only.searchText: the directive and inline-code patterns (measured linear) and the entity, URL and whitespace replaces.svg.ts: the entity and attribute regexes. They are anchored, or read from a 64 KiB window.Checks run locally (serially)
api/test/public-input.test.ts,core-ts/test/redos-174.test.ts,schedules/test/xml-scan.test.ts,agent-runtime/test/frontmatter-lines.test.ts+skills.test.ts,ui/test/markdown-editor-codec.test.tsx(the new ui test did not run in round 1; see item 6),docs/test/paths.test.ts+worker.test.ts: all green.turbo run test --concurrency=1: docs (3 files), agent-runtime (11), schedules (11), ui (16), astro-renderer (49), api (40): all green.EADDRNOTAVAIL,fetch failed, one hook timeout): this machine ran out of local ports. Rerun on its own,list-values-query.test.tspassed 18/18.store.test.ts. Its better-sqlite3 slice passed (156 tests; the other 281 belong to the two halves CI runs). The D1 and Durable Object halves need more connections than this machine can recycle, so CI runs them on Linux.pnpm lint(0 errors),pnpm size:check,pnpm design:lintandturbo run typecheck(20/20) are green.Review round 2 (second commit)
R1.
ancestorPathsis bounded for many short segments (core-ts/src/redirects.ts)REDIRECT_FROM_MAX_LENGTH(1024, the contract'smaxLengthforfrom), the walk starts at the longest ancestor within that length; then the//cut applies.from."/a".repeat(50_000)gives 512 ancestors totalling under 1024 × 513 characters, in under 100 ms. It used to give about 50,000 ancestors, quadratic in size, JSON-encoded into one SQL parameter on every 404.froms, including the length cap.R2. Directive lines stay on one line (
core-ts/src/search-rows.ts)DIRECTIVE_LINE:/^:{2,}[a-z][\w-]*(\{[^\n\r\u2028\u2029]*\})?[ \t]*$/gim.m, every line terminator starts a line, and[^\n]*ran across\r."::a{}x\r"× 14,000 took 1,168 ms; it now takes 0.4 ms.\ror U+2028/2029 is no longer read as one directive. Markdown from Studio and agents is\n-only.\n-only input (4,000 strings), and asearchTextcase on 100k characters of CR lines.R3.
stripFenceskeeps the next\n(core-ts/src/search-text-scan.ts)\nposition is kept and recomputed only once an opener passes it. With no\nleft, no later opener can have a body, so the scan stops.\rline ends, with U+2028 line ends, and with one\nat the very end. The 8,000-string fuzz still passes.R4.
mediaUrlValueskeeps the next>and the nexturl(schedules/src/xml-scan.ts)forwardFinderkeeps its lastindexOfanswer until a query passes it.'<enclosure url="a" '× n,'<enclosure>'× n, and tags followed by a 50k CDATA section with the>after it.xml-scan.tsfails the first of these (298 ms against the 100 ms bound). The 8,000-string fuzz still passes.R5. Every new test file is collected (checked with
vitest list <file>in each package):packages/api/test/public-input.test.tspackages/core-ts/test/redos-174.test.tspackages/schedules/test/xml-scan.test.tspackages/agent-runtime/test/frontmatter-lines.test.tsapps/docs/test/paths.test.ts(no vitest config; default include)packages/ui/test/markdown-blocks-redos.test.tsx(renamed from.test.ts)Checks for round 2:
redos-174,xml-scan,markdown-blocks-redos.test.tsx.turbo run test --concurrency=1: schedules (11 files), ui (17, one more than round 1: the renamed test), astro-renderer (49) and api (40), all green.store.test.ts: 60 files, 660 tests, all green.list-values-querypassed this time.store.test.ts. They exceed this machine's local ports.pnpm lint(0 errors),pnpm size:check,pnpm design:lintandturbo run typecheck(20/20) are green.Review round 3 (third commit, before 0.6.5)
1. Timing bounds follow the repo convention.
core-ts/test/names.test.ts).<item>, the mediaurlcases. The old code still takes seconds on every case.public-input.test.tswarms the app with a valid request first, then times two adversarial addresses against 1.5 s: 60k dots (under the body cap, answered 400; the old regex took about 2 s) and 200k dots (answered 413; the old code had no cap and took about 23 s).2. The
fromlength is counted in code points. The contract'smaxLength(1024) is checked by ajv in code points, soancestorPathsnow caps at 2 × 1024 UTF-16 units, still a constant. Afromof/plus 600 astral characters (601 code points, 1201 units) matches${from}/x, and still matches when the request path is longer than the cap. Under the round-2 cap it would not have matched.3. A
//at the cut. The//cut is computed on the whole path before truncating, so the first ancestor never ends in/. Tested with/+ 1022, 2046 and 2047as +//b: the first ancestor stops before the//, no ancestor ends in/, and the usable list equals the old one.4. More cases.
stripFences: 200k-character cases with U+2029 and CRLF line ends.<enclosure>and<media:content>tags,type=before and afterurl=, the wordurlin text anddata-url. It equals the old regex.-andurl, so it readsdata-url="…"as the url attribute. When it comes first in a tag, it wins over a laterurl=. The scanner keeps that, since this PR's promise is that results are unchanged. Counting only realurlattributes would be a small behaviour change for a follow-up.Checks for round 3:
redos-174(8 tests),xml-scan(5),public-input(4),frontmatter-lines(2),paths(2) andmarkdown-blocks-redos.test.tsx(2), all green.store.test.ts: 60 files, 661 tests. Thenstore.test.ts's better-sqlite3 slice: 156 tests, the slice that covers redirect matching. All green. Its D1 and Durable Object halves are left to CI, which ran them green on round 2.pnpm lint(0 errors),pnpm size:checkand the typecheck of core-ts, schedules and api are green.🤖 Generated with Claude Code